/*
 * Copyright 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.navigation.safe.args.generator.java

import androidx.navigation.safe.args.generator.BoolArrayType
import androidx.navigation.safe.args.generator.BoolType
import androidx.navigation.safe.args.generator.FloatArrayType
import androidx.navigation.safe.args.generator.FloatType
import androidx.navigation.safe.args.generator.IntArrayType
import androidx.navigation.safe.args.generator.IntType
import androidx.navigation.safe.args.generator.LongArrayType
import androidx.navigation.safe.args.generator.LongType
import androidx.navigation.safe.args.generator.NavWriter
import androidx.navigation.safe.args.generator.ObjectArrayType
import androidx.navigation.safe.args.generator.ObjectType
import androidx.navigation.safe.args.generator.ReferenceArrayType
import androidx.navigation.safe.args.generator.ReferenceType
import androidx.navigation.safe.args.generator.StringArrayType
import androidx.navigation.safe.args.generator.StringType
import androidx.navigation.safe.args.generator.ext.capitalize
import androidx.navigation.safe.args.generator.ext.toCamelCase
import androidx.navigation.safe.args.generator.ext.toCamelCaseAsVar
import androidx.navigation.safe.args.generator.models.Action
import androidx.navigation.safe.args.generator.models.Argument
import androidx.navigation.safe.args.generator.models.Destination
import com.squareup.javapoet.AnnotationSpec
import com.squareup.javapoet.ClassName
import com.squareup.javapoet.CodeBlock
import com.squareup.javapoet.FieldSpec
import com.squareup.javapoet.JavaFile
import com.squareup.javapoet.MethodSpec
import com.squareup.javapoet.ParameterSpec
import com.squareup.javapoet.TypeName
import com.squareup.javapoet.TypeSpec
import java.util.Locale
import javax.lang.model.element.Modifier

const val L = "\$L"
const val N = "\$N"
const val T = "\$T"
const val S = "\$S"
const val BEGIN_STMT = "\$["
const val END_STMT = "\$]"

class JavaNavWriter(private val useAndroidX: Boolean = true) : NavWriter<JavaCodeFile> {

    override fun generateDirectionsCodeFile(
        destination: Destination,
        parentDirectionsFileList: List<JavaCodeFile>
    ): JavaCodeFile {
        val className = destination.toClassName()
        val typeSpec =
            generateDestinationDirectionsTypeSpec(className, destination, parentDirectionsFileList)
        return JavaFile.builder(className.packageName(), typeSpec).build().toCodeFile()
    }

    private fun generateDestinationDirectionsTypeSpec(
        className: ClassName,
        destination: Destination,
        parentDirectionsFileList: List<JavaCodeFile>
    ): TypeSpec {
        val constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()

        val actionTypes =
            destination.actions.map { action -> action to generateDirectionsTypeSpec(action) }

        @Suppress("NAME_SHADOWING")
        val getters =
            actionTypes.map { (action, actionType) ->
                val annotations = Annotations.getInstance(useAndroidX)
                val methodName = action.id.javaIdentifier.toCamelCaseAsVar()
                if (action.args.isEmpty()) {
                    MethodSpec.methodBuilder(methodName)
                        .addAnnotation(annotations.NONNULL_CLASSNAME)
                        .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                        .returns(NAV_DIRECTION_CLASSNAME)
                        .addStatement(
                            "return new $T($L)",
                            ACTION_ONLY_NAV_DIRECTION_CLASSNAME,
                            action.id.accessor()
                        )
                        .build()
                } else {
                    val constructor = actionType.methodSpecs.find(MethodSpec::isConstructor)!!
                    val params = constructor.parameters.joinToString(", ") { param -> param.name }
                    val actionTypeName =
                        ClassName.get(
                            className.packageName(),
                            className.simpleName(),
                            actionType.name
                        )
                    MethodSpec.methodBuilder(methodName)
                        .addAnnotation(annotations.NONNULL_CLASSNAME)
                        .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                        .addParameters(constructor.parameters)
                        .returns(actionTypeName)
                        .addStatement("return new $T($params)", actionTypeName)
                        .build()
                }
            }

        // The parent destination list is ordered from the closest to the farthest parent of the
        // processing destination in the graph hierarchy.
        val parentGetters = mutableListOf<MethodSpec>()
        parentDirectionsFileList.forEach {
            val parentPackageName = it.wrapped.packageName
            val parentTypeSpec = it.wrapped.typeSpec
            parentTypeSpec.methodSpecs
                .filter { method ->
                    method.hasModifier(Modifier.STATIC) &&
                        getters.none { it.name == method.name } && // de-dupe local actions
                        parentGetters.none { it.name == method.name } // de-dupe parent actions
                }
                .forEach { actionMethod ->
                    val params = actionMethod.parameters.joinToString(", ") { param -> param.name }
                    val methodSpec =
                        MethodSpec.methodBuilder(actionMethod.name)
                            .addAnnotations(actionMethod.annotations)
                            .addModifiers(actionMethod.modifiers)
                            .addParameters(actionMethod.parameters)
                            .returns(actionMethod.returnType)
                            .addStatement(
                                "return $T.$L($params)",
                                ClassName.get(parentPackageName, parentTypeSpec.name),
                                actionMethod.name
                            )
                            .build()
                    parentGetters.add(methodSpec)
                }
        }

        return TypeSpec.classBuilder(className)
            .addModifiers(Modifier.PUBLIC)
            .addTypes(
                actionTypes
                    .filter { (action, _) -> action.args.isNotEmpty() }
                    .map { (_, actionType) -> actionType }
            )
            .addMethod(constructor)
            .addMethods(getters + parentGetters)
            .build()
    }

    internal fun generateDirectionsTypeSpec(action: Action): TypeSpec {
        val annotations = Annotations.getInstance(useAndroidX)
        val specs = ClassWithArgsSpecs(action.args, annotations, privateConstructor = true)
        val className = ClassName.get("", action.id.javaIdentifier.toCamelCase())

        val getDestIdMethod =
            MethodSpec.methodBuilder("getActionId")
                .addAnnotation(Override::class.java)
                .addModifiers(Modifier.PUBLIC)
                .returns(Int::class.java)
                .addStatement("return $L", action.id.accessor())
                .build()

        val additionalEqualsBlock =
            CodeBlock.builder()
                .apply {
                    beginControlFlow("if ($N() != that.$N())", getDestIdMethod, getDestIdMethod)
                        .apply { addStatement("return false") }
                    endControlFlow()
                }
                .build()

        val additionalHashCodeBlock =
            CodeBlock.builder()
                .apply { addStatement("result = 31 * result + $N()", getDestIdMethod) }
                .build()

        val toStringHeaderBlock =
            CodeBlock.builder()
                .apply {
                    add(
                        "$S + $L() + $S",
                        "${className.simpleName()}(actionId=",
                        getDestIdMethod.name,
                        "){"
                    )
                }
                .build()

        return TypeSpec.classBuilder(className)
            .addSuperinterface(NAV_DIRECTION_CLASSNAME)
            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
            .addField(specs.hashMapFieldSpec)
            .addMethod(specs.constructor())
            .addMethods(specs.setters(className))
            .addMethod(specs.toBundleMethod("getArguments", true))
            .addMethod(getDestIdMethod)
            .addMethods(specs.getters())
            .addMethod(specs.equalsMethod(className, additionalEqualsBlock))
            .addMethod(specs.hashCodeMethod(additionalHashCodeBlock))
            .addMethod(specs.toStringMethod(className, toStringHeaderBlock))
            .build()
    }

    override fun generateArgsCodeFile(destination: Destination): JavaCodeFile {
        val annotations = Annotations.getInstance(useAndroidX)
        val destName =
            destination.name
                ?: throw IllegalStateException("Destination with arguments must have name")
        val className = ClassName.get(destName.packageName(), "${destName.simpleName()}Args")
        val args = destination.args
        val specs = ClassWithArgsSpecs(args, annotations)

        val fromBundleMethod =
            MethodSpec.methodBuilder("fromBundle")
                .apply {
                    addAnnotation(annotations.NONNULL_CLASSNAME)
                    addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                    if (args.any { it.type is ObjectArrayType || it.type is ObjectType }) {
                        addAnnotation(
                            specs.suppressAnnotationSpec
                                .toBuilder()
                                .addMember("value", "$S", "deprecation")
                                .build()
                        )
                    } else {
                        addAnnotation(specs.suppressAnnotationSpec)
                    }
                    val bundle = "bundle"
                    addParameter(
                        ParameterSpec.builder(BUNDLE_CLASSNAME, bundle)
                            .addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
                            .build()
                    )
                    returns(className)
                    val result = "__result"
                    addStatement("$T $N = new $T()", className, result, className)
                    addStatement("$N.setClassLoader($T.class.getClassLoader())", bundle, className)
                    args.forEach { arg ->
                        addReadSingleArgBlock("containsKey", bundle, result, arg, specs) {
                            arg.type.addBundleGetStatement(this, arg, arg.sanitizedName, bundle)
                        }
                    }
                    addStatement("return $N", result)
                }
                .build()

        val fromSavedStateHandleMethod =
            MethodSpec.methodBuilder("fromSavedStateHandle")
                .apply {
                    addAnnotation(annotations.NONNULL_CLASSNAME)
                    addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                    addAnnotation(specs.suppressAnnotationSpec)
                    val savedStateHandle = "savedStateHandle"
                    addParameter(
                        ParameterSpec.builder(SAVED_STATE_HANDLE_CLASSNAME, savedStateHandle)
                            .addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
                            .build()
                    )
                    returns(className)
                    val result = "__result"
                    addStatement("$T $N = new $T()", className, result, className)
                    args.forEach { arg ->
                        addReadSingleArgBlock("contains", savedStateHandle, result, arg, specs) {
                            addStatement(
                                "$N = $N.get($S)",
                                arg.sanitizedName,
                                savedStateHandle,
                                arg.name
                            )
                        }
                    }
                    addStatement("return $N", result)
                }
                .build()

        val constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()

        val copyConstructor =
            MethodSpec.constructorBuilder()
                .addAnnotation(specs.suppressAnnotationSpec)
                .addModifiers(Modifier.PUBLIC)
                .addParameter(
                    ParameterSpec.builder(className, "original")
                        .addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
                        .build()
                )
                .addCode(specs.copyMapContents("this", "original"))
                .build()

        val fromMapConstructor =
            MethodSpec.constructorBuilder()
                .addAnnotation(specs.suppressAnnotationSpec)
                .addModifiers(Modifier.PRIVATE)
                .addParameter(HASHMAP_CLASSNAME, "argumentsMap")
                .addStatement(
                    "$N.$N.putAll($N)",
                    "this",
                    specs.hashMapFieldSpec.name,
                    "argumentsMap"
                )
                .build()

        val buildMethod =
            MethodSpec.methodBuilder("build")
                .addAnnotation(annotations.NONNULL_CLASSNAME)
                .addModifiers(Modifier.PUBLIC)
                .returns(className)
                .addStatement(
                    "$T result = new $T($N)",
                    className,
                    className,
                    specs.hashMapFieldSpec.name
                )
                .addStatement("return result")
                .build()

        val builderClassName = ClassName.get("", "Builder")
        val builderTypeSpec =
            TypeSpec.classBuilder("Builder")
                .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)
                .addField(specs.hashMapFieldSpec)
                .addMethod(copyConstructor)
                .addMethod(specs.constructor())
                .addMethod(buildMethod)
                .addMethods(specs.setters(builderClassName))
                .addMethods(specs.getters(true))
                .build()

        val typeSpec =
            TypeSpec.classBuilder(className)
                .addSuperinterface(NAV_ARGS_CLASSNAME)
                .addModifiers(Modifier.PUBLIC)
                .addField(specs.hashMapFieldSpec)
                .addMethod(constructor)
                .addMethod(fromMapConstructor)
                .addMethod(fromBundleMethod)
                .addMethod(fromSavedStateHandleMethod)
                .addMethods(specs.getters())
                .addMethod(specs.toBundleMethod("toBundle"))
                .addMethod(specs.toSavedStateHandleMethod())
                .addMethod(specs.equalsMethod(className))
                .addMethod(specs.hashCodeMethod())
                .addMethod(specs.toStringMethod(className))
                .addType(builderTypeSpec)
                .build()

        return JavaFile.builder(className.packageName(), typeSpec).build().toCodeFile()
    }

    private fun MethodSpec.Builder.addReadSingleArgBlock(
        containsMethodName: String,
        sourceVariableName: String,
        targetVariableName: String,
        arg: Argument,
        specs: ClassWithArgsSpecs,
        addGetStatement: MethodSpec.Builder.() -> Unit
    ) {
        beginControlFlow("if ($N.$containsMethodName($S))", sourceVariableName, arg.name)
        addStatement("$T $N", arg.type.typeName(), arg.sanitizedName)
        addGetStatement()
        addNullCheck(arg, arg.sanitizedName)
        addStatement(
            "$targetVariableName.$N.put($S, $N)",
            specs.hashMapFieldSpec,
            arg.name,
            arg.sanitizedName
        )
        nextControlFlow("else")
        if (arg.defaultValue == null) {
            addStatement(
                "throw new $T($S)",
                IllegalArgumentException::class.java,
                "Required argument \"${arg.name}\" is missing and does not have an " +
                    "android:defaultValue"
            )
        } else {
            addStatement(
                "$targetVariableName.$N.put($S, $L)",
                specs.hashMapFieldSpec,
                arg.name,
                arg.defaultValue.write()
            )
        }
        endControlFlow()
    }
}

private class ClassWithArgsSpecs(
    val args: List<Argument>,
    val androidAnnotations: Annotations,
    val privateConstructor: Boolean = false
) {

    val suppressAnnotationSpec =
        AnnotationSpec.builder(SuppressWarnings::class.java)
            .addMember("value", "$S", "unchecked")
            .build()

    val hashMapFieldSpec =
        FieldSpec.builder(HASHMAP_CLASSNAME, "arguments", Modifier.PRIVATE, Modifier.FINAL)
            .initializer("new $T()", HASHMAP_CLASSNAME)
            .build()

    fun setters(thisClassName: ClassName) =
        args.map { arg ->
            val capitalizedName = arg.sanitizedName.capitalize(Locale.US)
            MethodSpec.methodBuilder("set$capitalizedName")
                .apply {
                    addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
                    addAnnotation(suppressAnnotationSpec)
                    addModifiers(Modifier.PUBLIC)
                    addParameter(generateParameterSpec(arg))
                    addNullCheck(arg, arg.sanitizedName)
                    addStatement(
                        "this.$N.put($S, $N)",
                        hashMapFieldSpec.name,
                        arg.name,
                        arg.sanitizedName
                    )
                    addStatement("return this")
                    returns(thisClassName)
                }
                .build()
        }

    fun constructor() =
        MethodSpec.constructorBuilder()
            .apply {
                if (args.filterNot(Argument::isOptional).isNotEmpty()) {
                    addAnnotation(suppressAnnotationSpec)
                }
                addModifiers(if (privateConstructor) Modifier.PRIVATE else Modifier.PUBLIC)
                args.filterNot(Argument::isOptional).forEach { arg ->
                    addParameter(generateParameterSpec(arg))
                    addNullCheck(arg, arg.sanitizedName)
                    addStatement(
                        "this.$N.put($S, $N)",
                        hashMapFieldSpec.name,
                        arg.name,
                        arg.sanitizedName
                    )
                }
            }
            .build()

    fun toBundleMethod(name: String, addOverrideAnnotation: Boolean = false) =
        MethodSpec.methodBuilder(name)
            .apply {
                if (addOverrideAnnotation) {
                    addAnnotation(Override::class.java)
                }
                addAnnotation(suppressAnnotationSpec)
                addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
                addModifiers(Modifier.PUBLIC)
                returns(BUNDLE_CLASSNAME)
                val result = "__result"
                addStatement("$T $N = new $T()", BUNDLE_CLASSNAME, result, BUNDLE_CLASSNAME)
                args.forEach { arg ->
                    beginControlFlow("if ($N.containsKey($S))", hashMapFieldSpec.name, arg.name)
                        .apply {
                            addStatement(
                                "$T $N = ($T) $N.get($S)",
                                arg.type.typeName(),
                                arg.sanitizedName,
                                arg.type.typeName(),
                                hashMapFieldSpec.name,
                                arg.name
                            )
                            arg.type.addBundlePutStatement(this, arg, result, arg.sanitizedName)
                        }
                    if (arg.defaultValue != null) {
                        nextControlFlow("else").apply {
                            arg.type.addBundlePutStatement(
                                this,
                                arg,
                                result,
                                arg.defaultValue.write()
                            )
                        }
                    }
                    endControlFlow()
                }
                addStatement("return $N", result)
            }
            .build()

    fun toSavedStateHandleMethod(addOverrideAnnotation: Boolean = false) =
        MethodSpec.methodBuilder("toSavedStateHandle")
            .apply {
                if (addOverrideAnnotation) {
                    addAnnotation(Override::class.java)
                }
                addAnnotation(suppressAnnotationSpec)
                addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
                addModifiers(Modifier.PUBLIC)
                returns(SAVED_STATE_HANDLE_CLASSNAME)
                val result = "__result"
                addStatement(
                    "$T $N = new $T()",
                    SAVED_STATE_HANDLE_CLASSNAME,
                    result,
                    SAVED_STATE_HANDLE_CLASSNAME
                )
                args.forEach { arg ->
                    beginControlFlow("if ($N.containsKey($S))", hashMapFieldSpec.name, arg.name)
                        .apply {
                            addStatement(
                                "$T $N = ($T) $N.get($S)",
                                arg.type.typeName(),
                                arg.sanitizedName,
                                arg.type.typeName(),
                                hashMapFieldSpec.name,
                                arg.name
                            )
                            arg.type.addSavedStateHandleSetStatement(
                                this,
                                arg,
                                result,
                                arg.sanitizedName
                            )
                        }
                    if (arg.defaultValue != null) {
                        nextControlFlow("else").apply {
                            arg.type.addSavedStateHandleSetStatement(
                                this,
                                arg,
                                result,
                                arg.defaultValue.write()
                            )
                        }
                    }
                    endControlFlow()
                }
                addStatement("return $N", result)
            }
            .build()

    fun copyMapContents(to: String, from: String) =
        CodeBlock.builder()
            .addStatement(
                "$N.$N.putAll($N.$N)",
                to,
                hashMapFieldSpec.name,
                from,
                hashMapFieldSpec.name
            )
            .build()

    fun getters(isBuilder: Boolean = false) =
        args.map { arg ->
            MethodSpec.methodBuilder(getterFromArgName(arg.sanitizedName))
                .apply {
                    addModifiers(Modifier.PUBLIC)
                    if (!isBuilder) {
                        addAnnotation(suppressAnnotationSpec)
                    } else {
                        addAnnotation(
                            AnnotationSpec.builder(SuppressWarnings::class.java)
                                .addMember("value", "{$S,$S}", "unchecked", "GetterOnBuilder")
                                .build()
                        )
                    }
                    if (arg.type.allowsNullable()) {
                        if (arg.isNullable) {
                            addAnnotation(androidAnnotations.NULLABLE_CLASSNAME)
                        } else {
                            addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
                        }
                    }
                    addStatement(
                        "return ($T) $N.get($S)",
                        arg.type.typeName(),
                        hashMapFieldSpec.name,
                        arg.name
                    )
                    returns(arg.type.typeName())
                }
                .build()
        }

    fun equalsMethod(className: ClassName, additionalCode: CodeBlock? = null) =
        MethodSpec.methodBuilder("equals")
            .apply {
                addAnnotation(Override::class.java)
                addModifiers(Modifier.PUBLIC)
                addParameter(TypeName.OBJECT, "object")
                addCode(
                    """
                if (this == object) {
                    return true;
                }
                if (object == null || getClass() != object.getClass()) {
                    return false;
                }

            """
                        .trimIndent()
                )
                addStatement("$T that = ($T) object", className, className)
                args.forEach { (name, type, _, _, sanitizedName) ->
                    beginControlFlow(
                            "if ($N.containsKey($S) != that.$N.containsKey($S))",
                            hashMapFieldSpec,
                            name,
                            hashMapFieldSpec,
                            name
                        )
                        .apply { addStatement("return false") }
                        .endControlFlow()
                    val getterName = getterFromArgName(sanitizedName, "()")
                    val compareExpression =
                        when (type) {
                            IntType,
                            BoolType,
                            ReferenceType,
                            LongType -> "$getterName != that.$getterName"
                            FloatType -> "Float.compare(that.$getterName, $getterName) != 0"
                            StringType,
                            IntArrayType,
                            LongArrayType,
                            FloatArrayType,
                            StringArrayType,
                            BoolArrayType,
                            ReferenceArrayType,
                            is ObjectArrayType,
                            is ObjectType ->
                                "$getterName != null ? !$getterName.equals(that.$getterName) " +
                                    ": that.$getterName != null"
                            else -> throw IllegalStateException("unknown type: $type")
                        }
                    beginControlFlow("if ($N)", compareExpression).apply {
                        addStatement("return false")
                    }
                    endControlFlow()
                }
                if (additionalCode != null) {
                    addCode(additionalCode)
                }
                addStatement("return true")
                returns(TypeName.BOOLEAN)
            }
            .build()

    private fun getterFromArgName(sanitizedName: String, suffix: String = ""): String {
        val capitalizedName = sanitizedName.capitalize(Locale.US)
        return "get${capitalizedName}$suffix"
    }

    fun hashCodeMethod(additionalCode: CodeBlock? = null) =
        MethodSpec.methodBuilder("hashCode")
            .apply {
                addAnnotation(Override::class.java)
                addModifiers(Modifier.PUBLIC)
                addStatement("int result = 1")
                args.forEach { (_, type, _, _, sanitizedName) ->
                    val getterName = getterFromArgName(sanitizedName, "()")
                    val hashCodeExpression =
                        when (type) {
                            IntType,
                            ReferenceType -> getterName
                            FloatType -> "Float.floatToIntBits($getterName)"
                            IntArrayType,
                            LongArrayType,
                            FloatArrayType,
                            StringArrayType,
                            BoolArrayType,
                            ReferenceArrayType,
                            is ObjectArrayType -> "java.util.Arrays.hashCode($getterName)"
                            StringType,
                            is ObjectType -> "($getterName != null ? $getterName.hashCode() : 0)"
                            BoolType -> "($getterName ? 1 : 0)"
                            LongType -> "(int)($getterName ^ ($getterName >>> 32))"
                            else -> throw IllegalStateException("unknown type: $type")
                        }
                    addStatement("result = 31 * result + $N", hashCodeExpression)
                }
                if (additionalCode != null) {
                    addCode(additionalCode)
                }
                addStatement("return result")
                returns(TypeName.INT)
            }
            .build()

    fun toStringMethod(className: ClassName, toStringHeaderBlock: CodeBlock? = null) =
        MethodSpec.methodBuilder("toString")
            .apply {
                addAnnotation(Override::class.java)
                addModifiers(Modifier.PUBLIC)
                addCode(
                    CodeBlock.builder()
                        .apply {
                            if (toStringHeaderBlock != null) {
                                add("${BEGIN_STMT}return $L", toStringHeaderBlock)
                            } else {
                                add("${BEGIN_STMT}return $S", "${className.simpleName()}{")
                            }
                            args.forEachIndexed { index, (_, _, _, _, sanitizedName) ->
                                val getterName = getterFromArgName(sanitizedName, "()")
                                val prefix = if (index == 0) "" else ", "
                                add("\n+ $S + $L", "$prefix$sanitizedName=", getterName)
                            }
                            add("\n+ $S;\n$END_STMT", "}")
                        }
                        .build()
                )
                returns(ClassName.get(String::class.java))
            }
            .build()

    private fun generateParameterSpec(arg: Argument): ParameterSpec {
        return ParameterSpec.builder(arg.type.typeName(), arg.sanitizedName)
            .apply {
                if (arg.type.allowsNullable()) {
                    if (arg.isNullable) {
                        addAnnotation(androidAnnotations.NULLABLE_CLASSNAME)
                    } else {
                        addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
                    }
                }
            }
            .build()
    }
}

internal fun MethodSpec.Builder.addNullCheck(arg: Argument, variableName: String) {
    if (arg.type.allowsNullable() && !arg.isNullable) {
        beginControlFlow("if ($N == null)", variableName).apply {
            addStatement(
                "throw new $T($S)",
                IllegalArgumentException::class.java,
                "Argument \"${arg.name}\" is marked as non-null but was passed a null value."
            )
        }
        endControlFlow()
    }
}

internal fun Destination.toClassName(): ClassName {
    val destName = name ?: throw IllegalStateException("Destination with actions must have name")
    return ClassName.get(destName.packageName(), "${destName.simpleName()}Directions")
}
